Web Push 可以让网站在用户授权后,即使页面未打开,也向用户发送通知。本文以 Nuxt 3 为例,介绍如何使用 web-push 实现浏览器推送。

一、工作原理

Web Push 的基本流程如下:

  1. 前端注册 Service Worker。

  2. 用户授权通知权限。

  3. 浏览器生成 Push Subscription。

  4. 前端将 Subscription 发送到 Nuxt 服务端。

  5. 服务端保存 Subscription。

  6. 服务端使用 web-push 发送消息。

  7. Service Worker 接收消息并显示通知。

二、安装依赖

安装 web-push

npm install web-push

生成 VAPID 密钥:

npx web-push generate-vapid-keys

命令会生成一对公钥和私钥。

在项目根目录创建 .env

VAPID_PUBLIC_KEY=你的_public_key
VAPID_PRIVATE_KEY=你的_private_key
VAPID_SUBJECT=mailto:admin@example.com

其中:

  • VAPID_PUBLIC_KEY:前端订阅时使用。

  • VAPID_PRIVATE_KEY:仅服务端使用。

  • VAPID_SUBJECT:通常填写管理员邮箱。

三、配置 Nuxt 运行时变量

修改 nuxt.config.ts

export default defineNuxtConfig({
  runtimeConfig: {
    vapidPrivateKey: process.env.VAPID_PRIVATE_KEY,
    vapidSubject: process.env.VAPID_SUBJECT,

    public: {
      vapidPublicKey: process.env.VAPID_PUBLIC_KEY
    }
  }
})

私钥放在非 public 配置中,避免泄露给客户端。

四、创建 Service Worker

在 Nuxt 项目中创建:

public/sw.js

内容如下:

self.addEventListener('push', event => {
  let data = {}

  try {
    data = event.data ? event.data.json() : {}
  } catch {
    data = {
      title: '新消息',
      body: event.data ? event.data.text() : ''
    }
  }

  const title = data.title || '新消息'

  event.waitUntil(
    self.registration.showNotification(title, {
      body: data.body || '',
      icon: data.icon || '/icon-192.png',
      badge: data.badge || '/icon-192.png',
      data: {
        url: data.url || '/'
      }
    })
  )
})

self.addEventListener('notificationclick', event => {
  event.notification.close()

  const url = event.notification.data?.url || '/'

  event.waitUntil(
    clients.matchAll({
      type: 'window',
      includeUncontrolled: true
    }).then(windowClients => {
      for (const client of windowClients) {
        if ('focus' in client) {
          client.navigate(url)
          return client.focus()
        }
      }

      return clients.openWindow(url)
    })
  )
})

这个 Service Worker 主要完成两个功能:

  • 接收推送并显示通知。

  • 用户点击通知后打开指定页面。

五、创建订阅接口

创建文件:

server/api/push/subscribe.post.ts
import webpush from 'web-push'

export default defineEventHandler(async event => {
  const config = useRuntimeConfig()
  const subscription = await readBody(event)

  webpush.setVapidDetails(
    config.vapidSubject,
    config.public.vapidPublicKey,
    config.vapidPrivateKey
  )

  // 实际项目中应保存到数据库
  console.log('收到订阅:', subscription)

  return {
    success: true,
    message: '订阅成功'
  }
})

前端提交的 Subscription 通常包含:

{
  "endpoint": "...",
  "expirationTime": null,
  "keys": {
    "p256dh": "...",
    "auth": "..."
  }
}

生产环境中应该将这些数据与用户 ID 绑定后保存到数据库。

六、创建发送推送接口

创建文件:

server/api/push/send.post.ts
import webpush from 'web-push'

export default defineEventHandler(async event => {
  const config = useRuntimeConfig()
  const body = await readBody(event)

  webpush.setVapidDetails(
    config.vapidSubject,
    config.public.vapidPublicKey,
    config.vapidPrivateKey
  )

  const subscription = body.subscription

  if (!subscription) {
    throw createError({
      statusCode: 400,
      statusMessage: '缺少 subscription'
    })
  }

  const payload = JSON.stringify({
    title: body.title || 'Nuxt 推送',
    body: body.message || '你收到一条新消息',
    url: body.url || '/'
  })

  try {
    await webpush.sendNotification(subscription, payload)

    return {
      success: true
    }
  } catch (error: any) {
    console.error(error)

    throw createError({
      statusCode: 500,
      statusMessage: '推送发送失败'
    })
  }
})

实际项目中不应该从请求参数直接接收 Subscription,而应该根据用户身份从数据库查询对应的订阅信息。

七、前端订阅推送

例如在 pages/index.vue 中实现:

<script setup lang="ts">
const config = useRuntimeConfig()

const subscribed = ref(false)
const message = ref('')

function urlBase64ToUint8Array(base64String: string) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4)
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/')

  const rawData = window.atob(base64)

  return Uint8Array.from(
    [...rawData].map(char => char.charCodeAt(0))
  )
}

async function subscribePush() {
  if (!('serviceWorker' in navigator)) {
    message.value = '浏览器不支持 Service Worker'
    return
  }

  if (!('PushManager' in window)) {
    message.value = '浏览器不支持 Web Push'
    return
  }

  const permission = await Notification.requestPermission()

  if (permission !== 'granted') {
    message.value = '用户拒绝了通知权限'
    return
  }

  const registration = await navigator.serviceWorker.register('/sw.js')

  let subscription = await registration.pushManager.getSubscription()

  if (!subscription) {
    subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        config.public.vapidPublicKey
      )
    })
  }

  await $fetch('/api/push/subscribe', {
    method: 'POST',
    body: subscription.toJSON()
  })

  subscribed.value = true
  message.value = '订阅成功'
}

async function sendTestPush() {
  const registration = await navigator.serviceWorker.ready
  const subscription = await registration.pushManager.getSubscription()

  if (!subscription) {
    message.value = '请先订阅推送'
    return
  }

  await $fetch('/api/push/send', {
    method: 'POST',
    body: {
      subscription: subscription.toJSON(),
      title: '测试通知',
      message: '这是一条来自 Nuxt 的 Web Push 消息',
      url: '/'
    }
  })

  message.value = '推送已发送'
}
</script>

<template>
  <div>
    <button @click="subscribePush">
      订阅通知
    </button>

    <button
      :disabled="!subscribed"
      @click="sendTestPush"
    >
      发送测试通知
    </button>

    <p>{{ message }}</p>
  </div>
</template>

八、测试流程

启动 Nuxt 项目:

npm run dev

然后访问:

http://localhost:3000

依次执行:

  1. 点击“订阅通知”。

  2. 允许浏览器通知权限。

  3. 点击“发送测试通知”。

  4. 浏览器显示推送通知。

本地开发通常可以使用 localhost,但生产环境必须使用 HTTPS。

九、生产环境注意事项

1. 使用 HTTPS

Service Worker 和 Web Push 通常要求安全上下文:

  • 生产环境必须使用 HTTPS。

  • 本地开发可以使用 localhost

2. 不要暴露私钥

以下变量只能在服务端使用:

VAPID_PRIVATE_KEY
VAPID_SUBJECT

前端只能获取:

VAPID_PUBLIC_KEY

3. 保存订阅信息

Subscription 应保存到数据库,并与用户绑定,例如:

user_id
endpoint
p256dh
auth
created_at
updated_at

发送时,根据用户 ID 查找订阅信息。

4. 删除失效订阅

webpush.sendNotification() 返回 404410 时,通常代表订阅已经失效,应从数据库删除。

5. 处理重复订阅

用户重复进入页面时,可以先调用:

registration.pushManager.getSubscription()

如果已经存在订阅,就不需要重复创建。

6. 需要用户主动授权

通知权限最好在用户点击按钮后申请,不要在页面加载时自动弹出授权窗口。

十、PWA 与 Web Push 的关系

Web Push 不一定要求完整的 PWA,但必须使用 Service Worker。

如果希望网站能够:

  • 安装到桌面;

  • 像 App 一样独立运行;

  • 显示应用图标;

  • 离线访问部分内容;

则还需要配置 PWA,包括:

  • manifest.json

  • 应用图标

  • Service Worker

  • display: standalone

因此,Web Push 是推送能力,PWA 是网站应用化能力,两者可以结合使用,但不是完全等同的技术。